昨天我們先建立了一個基本觀念:
模型負責判斷,Harness 負責讓判斷可以被執行、觀察與限制。
但還有一個問題沒有回答:
為什麼一般聊天模型回答一次就停止,Agent 卻可以讀檔案、執行工具、看到結果,再繼續下一步?
答案通常沒有想像中複雜。
Agent 最核心的控制流程,往往只是一個 while 迴圈。
真正重要的不是迴圈本身,而是 Harness 在每一輪完成三件事:
messages
stop_reason 判斷模型想結束還是使用工具今天我們先拿掉 Memory、Planning、Subagent、Retry 和 Permission,只留下最小 Agent Loop。
一般模型呼叫是單向的:
使用者輸入
↓
模型
↓
文字輸出
例如:
response = model.generate(
"請計算 23 乘以 19"
)
print(response.text)
模型可以直接回答,也可能表示它想使用計算工具。
但模型回傳 Tool Call 之後,事情不會自動繼續。
Harness 還要:
因此,Agent Loop 比較像:
messages[]
↓
呼叫模型
↓
檢查 stop_reason
├── end_turn → 回傳最終答案
└── tool_use → 執行工具
↓
把結果加入 messages[]
↓
下一輪
messages[]messages 是目前任務的工作紀錄。
它可能包含:
messages = [
{
"role": "user",
"content": "請計算 23 乘以 19"
}
]
模型要求使用工具後,Harness 會加入模型的 Tool Call:
messages.append({
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"name": "multiply",
"arguments": {
"a": 23,
"b": 19
}
}
]
})
工具執行完成後,再加入結果:
messages.append({
"role": "tool",
"tool_call_id": "call_1",
"content": "437"
})
下一輪模型看到的不只是原始問題,而是完整過程:
使用者要求計算
模型決定呼叫 multiply
工具回傳 437
模型才能根據新觀察產生最終答案。
所以 messages[] 不只是聊天紀錄。
在最小 Agent Loop 裡,它同時扮演:
stop_reasonHarness 必須知道模型為什麼停止輸出。
最簡化後,可以先分成兩種:
tool_use
end_turn
tool_use 表示:
我現在還沒有完成任務,需要 Harness 幫我執行一個行動。
end_turn 表示:
我不需要更多工具,可以把目前輸出當作最終答案。
因此,Harness 不應該只檢查模型有沒有回傳文字。
模型可能同時產生一段說明和一個 Tool Call。
真正控制流程的是結構化狀態:
if response.stop_reason == "end_turn":
return response.text
if response.stop_reason == "tool_use":
...
Tool Result 是 Agent 對環境的觀察。
例如模型呼叫:
{
"name": "multiply",
"arguments": {
"a": 23,
"b": 19
}
}
Harness 執行:
result = multiply(a=23, b=19)
得到:
437
如果這個結果沒有被加入 messages[],模型就不知道工具是否成功,也看不到計算結果。
因此,Agent Loop 的基本循環其實是:
Reason
↓
Act
↓
Observe
↓
Reason again
模型負責 Reason。
工具負責 Act。
Harness 負責把 Observation 送回下一輪。
先看一段與 Provider 無關的核心程式碼:
def run_agent(model, user_input, tools):
messages = [
{
"role": "user",
"content": user_input,
}
]
while True:
response = model.generate(
messages=messages,
tools=list(tools.values()),
)
messages.append(response.to_message())
if response.stop_reason == "end_turn":
return response.text
if response.stop_reason != "tool_use":
raise RuntimeError(
f"Unknown stop reason: {response.stop_reason}"
)
for tool_call in response.tool_calls:
tool = tools.get(tool_call.name)
if tool is None:
result = {
"ok": False,
"error": f"Unknown tool: {tool_call.name}",
}
else:
try:
value = tool(**tool_call.arguments)
result = {
"ok": True,
"value": value,
}
except Exception as exc:
result = {
"ok": False,
"error": str(exc),
}
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
這段程式只做四件事:
messages
stop_reason 分流messages
Agent 能夠持續工作的原因,就藏在這個 while True 裡。
為了把控制流程看清楚,下面不連接任何 LLM API,而是使用一個簡單的 DemoModel 模擬模型兩輪行為。
第一輪要求呼叫 multiply。
第二輪讀到工具結果後,回傳最終答案。
from dataclasses import dataclass
from typing import Any, Callable, Literal
@dataclass
class ToolCall:
id: str
name: str
arguments: dict[str, Any]
@dataclass
class ModelResponse:
stop_reason: Literal["tool_use", "end_turn"]
text: str | None = None
tool_calls: list[ToolCall] | None = None
def to_message(self) -> dict[str, Any]:
message: dict[str, Any] = {
"role": "assistant",
"content": self.text,
}
if self.tool_calls:
message["tool_calls"] = [
{
"id": call.id,
"name": call.name,
"arguments": call.arguments,
}
for call in self.tool_calls
]
return message
class DemoModel:
def generate(
self,
messages: list[dict[str, Any]],
tools: list[str],
) -> ModelResponse:
tool_messages = [
message
for message in messages
if message["role"] == "tool"
]
if not tool_messages:
return ModelResponse(
stop_reason="tool_use",
tool_calls=[
ToolCall(
id="call_1",
name="multiply",
arguments={"a": 23, "b": 19},
)
],
)
result = tool_messages[-1]["content"]
return ModelResponse(
stop_reason="end_turn",
text=f"23 × 19 = {result['value']}",
)
def multiply(a: int, b: int) -> int:
return a * b
def run_agent(
model: DemoModel,
user_input: str,
tools: dict[str, Callable[..., Any]],
max_turns: int = 8,
) -> str:
messages: list[dict[str, Any]] = [
{
"role": "user",
"content": user_input,
}
]
for _ in range(max_turns):
response = model.generate(
messages=messages,
tools=list(tools.keys()),
)
messages.append(response.to_message())
if response.stop_reason == "end_turn":
if response.text is None:
raise RuntimeError(
"Model ended without a final answer."
)
return response.text
if response.stop_reason != "tool_use":
raise RuntimeError(
f"Unknown stop reason: {response.stop_reason}"
)
for tool_call in response.tool_calls or []:
tool = tools.get(tool_call.name)
if tool is None:
result = {
"ok": False,
"error": f"Unknown tool: {tool_call.name}",
}
else:
try:
result = {
"ok": True,
"value": tool(**tool_call.arguments),
}
except Exception as exc:
result = {
"ok": False,
"error": str(exc),
}
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
raise RuntimeError(
f"Agent exceeded the maximum of {max_turns} turns."
)
if __name__ == "__main__":
answer = run_agent(
model=DemoModel(),
user_input="請計算 23 乘以 19",
tools={
"multiply": multiply,
},
)
print(answer)
執行結果:
23 × 19 = 437
雖然這個 Demo 沒有真的連接 LLM,但它保留了 Agent Loop 最重要的控制流程:
第一輪
模型要求使用 multiply
↓
Harness 執行 multiply
↓
Harness 保存 Tool Result
第二輪
模型讀取 Tool Result
↓
模型回傳最終答案
↓
Harness 結束 Loop
把 DemoModel 換成真實模型 Provider 後,Loop 的核心結構不需要大幅改變。
while True?前面的核心版本使用:
while True:
但 Production Agent 不能無限制執行。
模型可能:
end_turn
因此,即使是最小範例,也應該加入 Turn Budget:
for turn in range(max_turns):
...
超過限制後直接停止:
raise RuntimeError(
f"Agent exceeded the maximum of {max_turns} turns."
)
這是第一個非常簡單的 Harness Control。
它不會讓模型更聰明,但可以避免 Agent 永遠執行。
end_turn 真的代表任務完成嗎?現在的 Loop 只要收到:
stop_reason = end_turn
就直接回傳答案。
但這裡必須區分兩件事:
模型停止
任務完成
兩者不一定相同。
模型可能因為以下原因停止:
因此,今天的版本只能表示:
模型決定停止這一輪 Agent Loop。
它還不能證明:
任務已經被正確完成。
例如 Coding Agent 說:
已經修正問題。
真正完成可能還需要:
測試通過
Lint 通過
預期檔案存在
輸出 Schema 合法
沒有修改禁止的檔案
這些外部驗證會在後面的文章逐步加入。
在範例中,工具失敗時不會立刻讓整個程式 Crash。
Harness 會把錯誤包成 Tool Result:
result = {
"ok": False,
"error": str(exc),
}
再交回模型。
原因是:
工具失敗也是一種 Observation。
模型可能根據錯誤決定:
如果 Harness 在每次工具失敗時都立刻終止,模型就沒有恢復機會。
但反過來說,也不能允許無限重試。
之後還需要加入:
今天先保留最小原則:
把工具錯誤轉換成模型可以觀察的結構化結果。
即使只有幾十行,問題已經開始出現。
模型不斷呼叫工具,永遠不回傳 end_turn。
目前使用 max_turns 限制。
模型可能產生不存在的工具名稱。
目前把錯誤送回模型,而不是直接 Crash。
工具參數缺少欄位或型別錯誤。
目前由 try/except 接住,但還沒有正式 Schema Validation。
如果工具回傳幾萬行 Log,全部加入 messages[] 可能快速耗盡 Context。
之後需要截斷、摘要或外部儲存。
模型回傳 end_turn,但實際結果還沒有通過驗證。
之後需要 Completion Gate。
模型可能一次要求多個工具。
目前依序執行,但有些工具可以平行,有些則有相依關係。
之後需要更明確的執行策略。
這些問題說明一件事:
Agent Loop 本身很小,Production Engineering 幾乎都發生在 Loop 周圍。
假設 Agent 已經執行八輪,還沒有完成。
可以有兩種做法。
第一種是在 Prompt 裡寫:
請不要執行太久,最多使用八輪。
第二種是在 Harness 裡寫:
for _ in range(8):
...
第二種更可靠。
因為「最多八輪」是一個明確、可計算、可強制執行的規則,不需要模型判斷。
這也是後面 Graph Engineering 會持續討論的核心:
已經確定的控制流程,通常應該寫進程式碼,而不是每一輪都交給模型決定。
模型適合處理:
程式碼適合處理:
昨天的系統只有:
輸入 → 模型 → 輸出
今天加入:
messages[]
stop_reason
tool execution
tool result
turn budget
系統因此可以:
這就是最小 Agent Loop。
Agent 能夠持續執行,不是因為模型內部藏著一個神祕的 Agent 系統。
最小版本可能只是一個迴圈:
呼叫模型
↓
檢查 stop_reason
↓
執行 Tool Call
↓
保存 Tool Result
↓
再次呼叫模型
其中三個最重要的概念是:
messages[] 保存工作狀態stop_reason 控制流程分支但這個版本還非常不安全。
模型現在可以呼叫任何註冊工具,而 Harness 還沒有處理:
下一篇我們會繼續拆解:
Tool Calling 和 Tool Runtime 到底有什麼差別?
模型產生一段 Tool Call,不代表工具已經被正確、安全地執行。
完整程式碼與系列內容:https://github.com/hardness1020/awesome-agent-architecture